Skip to content

feat: wanted→offer matching, shown after posting#953

Merged
edwh merged 9 commits into
masterfrom
feature/wanted-offer-match
Jul 17, 2026
Merged

feat: wanted→offer matching, shown after posting#953
edwh merged 9 commits into
masterfrom
feature/wanted-offer-match

Conversation

@edwh

@edwh edwh commented Jul 4, 2026

Copy link
Copy Markdown
Member

Update (review feedback): matches now show after posting, not during compose.
Per @edwh — "get the posts out of them first, then show these after they've posted." The WantedMatches panel has been removed from the find whereami compose step (desktop + mobile) so it never distracts from completing the post. It now renders on the myposts landing page the poster is sent to immediately after posting, for a just-posted WANTED only (query + location derived from the posted item via history state). Dismiss copy updated for the post-success context; added myposts tests (WANTED shows / OFFER hides). The panel reuses the same recommendation-card visual shown in #951. (A live post-success screenshot needs an authenticated session — not capturable from the empty local worktree.)

Summary

Merge after #951 (this branch builds on the similar-posts branch — it shares the reach helper, the MessageSummary-based card approach, and the wanted_match source already wired into the recommendations funnel).

Phase 3 of the vector-search rollout: while someone composes a WANTED, show them existing OFFERs near them that match — so they can grab something that's already available instead of waiting.

  • GET /message/matches — given the item text of a wanted-in-progress and the poster's chosen location, returns existing OFFERs within ~15km that semantically match. Because the wanted isn't saved yet, this embeds the query text via the sidecar (unlike /similar, which reuses a stored embedding), then does the in-memory cosine scan filtered to Offers in a lat/lng box around the location. It applies a search-grade MinVectorScore (this is a match claim, not exploration), reach-filters against the poster's chosen location (a matched offer they couldn't reply to is never shown — reusing Phase 2's shared ReachBlockedSet), and excludes the poster's own offers. Behind a FEATURE_WANTED_MATCH=off killswitch.
  • The panel never blocks posting. WantedMatches.vue renders under the postcode chooser on both the desktop (find/whereami.vue) and mobile (find/mobile/whereami.vue) compose steps once a location is chosen. It's async, emits nothing that gates the Next button, and opens each match in a new tab (window.open(..., '_blank')) so the in-progress draft is never lost. A dismiss link ("Not what I'm looking for - keep posting") collapses it. Even one match is worth showing, so it appears with ≥1 result.
  • Deflection is a success, not a failure. If a composer finds what they wanted among the offers and replies instead of finishing their post, that's the ideal outcome. It's measured through the same recommendations funnel as Phase 2 (source wanted_match): impressions when the panel shows, clicks when a card is opened, and attributed replies within 7 days — all already visible on the ModTools "Recommendations" sysadmin tab.

Failure-safety

The compose flow must never break because matching couldn't run. Every failure path (feature off, no location, empty query, sidecar/embedding unavailable, no matches) returns an empty list and the panel simply hides — there is no path that errors or blocks the poster.

Code Quality Review

(To be completed by the code-review pass before merge.) Reuses Phase 2's shared ReachBlockedSet and the MessageSummary card; the new-tab open via @expand avoids nested-anchor issues while preserving the draft; the route is registered before /message/:ids so "matches" isn't parsed as a message id.

Future Improvements

  • MyPosts "while you wait" placement — the plan also envisaged showing matches on the post-submit MyPosts landing. That needs router-state plumbing (detecting the just-submitted wanted + the user's location) and is deferred; the two in-compose placements deliver the core value.
  • Deflection metric — explicitly tracking "wanted draft abandoned after a match click that led to a reply" would quantify deflection directly, rather than inferring it from the funnel.
  • Offer→wanted direction — the symmetric case (someone posting an Offer sees matching Wanteds) is a natural follow-up.

Test Plan

  • Go (worktree status API): all tests pass (3409✓). New TestMatches* (5): returns nearby same-type offers (excludes Wanteds and out-of-box offers), flag-off empty, no-location empty, excludes the caller's own offers, reach-filtered (out-of-reach dropped, no-reach-row kept).
  • Vitest (worktree status API): WantedMatches.spec.js 6✓ (no render on 0 matches, renders + calls matches with query+location, impression counted once as wanted_match, opens in a new tab tagged wanted_match, dismiss hides, no fetch without a location). Full suite green.

Rollback: FEATURE_WANTED_MATCH=off disables the panel server-side (no deploy).

@netlify

netlify Bot commented Jul 4, 2026

Copy link
Copy Markdown

Deploy Preview for golden-caramel-d2c3a7 ready!

Name Link
🔨 Latest commit ddfc71a
🔍 Latest deploy log https://app.netlify.com/projects/golden-caramel-d2c3a7/deploys/6a5a2e86bb983d0008ded51f
😎 Deploy Preview https://deploy-preview-953--golden-caramel-d2c3a7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Failed CI build failed and removed CI: Running CI build is currently running labels Jul 4, 2026
@edwh
edwh force-pushed the feature/wanted-offer-match branch from e930c53 to 50b6a4b Compare July 4, 2026 21:05
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Failed CI build failed CI: Running CI build is currently running labels Jul 4, 2026
@edwh edwh changed the title feat: wanted→offer matching at post time feat: wanted→offer matching, shown after posting Jul 5, 2026
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jul 5, 2026
@edwh
edwh force-pushed the feature/wanted-offer-match branch from dfd2b96 to 387e215 Compare July 5, 2026 17:36
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jul 5, 2026
@edwh edwh added the Blocked PR is blocked and cannot be merged label Jul 6, 2026
edwh added 5 commits July 12, 2026 18:28
…urement

- GET /message/:id/similar: stored-embedding cosine, same-type, excludes
  source+own-author, MinSimilarScore, reach-filtered (shared ReachBlockedSet
  helper, also used by the browse read path), FEATURE_SIMILAR_POSTS killswitch.
- Source-tagged impressions via MarkSeen (first-touch), threaded through the FE.
- SimilarPosts.vue: lazy strip on the message page reusing MessageSummary,
  10% userid holdout, >=3-results gate, one source-tagged impression per view.
- Recommendations sysadmin tab: per-source impressions/clicks/CTR/attributed
  replies (7-day window) + holdout-vs-shown reply-rate comparison.
…shing message page)

The v-observe-visibility binding used {callback, once, intersection} but the
plugin expects {callback, options}. With no 'options' key it read
options.observeFullElement off undefined at beforeMount, throwing
'Cannot read properties of undefined (reading observeFullElement)' and turning
every message page into a 500 error page (caught by Playwright, not vitest which
stubs the directive). Match OurMessage.vue's proven shape; this plugin has no
'once' so guard re-entry with a loaded flag; add .catch() to the fire-and-forget
markSeen (review finding).
- GET /message/matches: embeds the wanted's item text, finds Offers within
  ~15km of the poster's chosen location, reach-filtered against that location
  (shared ReachBlockedSet), excludes own posts, FEATURE_WANTED_MATCH killswitch.
- WantedMatches.vue: async, non-blocking panel under the postcode chooser on the
  desktop and mobile compose steps; reuses MessageSummary; opens each match in a
  new tab so the draft is preserved; dismissable. Impressions tagged wanted_match
  (already in the recommendations funnel).
- Every failure path (off / no location / no match / sidecar down) hides the
  panel; it can never break or block posting.
Addresses the Phase 3 review: a monotonic load token discards an out-of-order
response from a superseded query/location, and the fire-and-forget markSeen now
.catch()es so a non-401 rejection never surfaces as an unhandled promise.
Per review: don't distract the poster mid-compose. Remove the WantedMatches
panel from the find whereami step (desktop + mobile) and instead show it on the
myposts landing page the poster is sent to right after posting - but only for a
just-posted WANTED (derived from the posted item + location in history state).
Updates the dismiss copy for the post-success context and adds myposts tests
covering the WANTED-shows / OFFER-hides cases.
@edwh
edwh force-pushed the feature/wanted-offer-match branch from 387e215 to 62ec367 Compare July 12, 2026 17:29
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jul 12, 2026
edwh added 3 commits July 17, 2026 09:44
The panel could never render after a real WANTED post. freegleIt() only
pushed the posted ids into history state for Offers:

    if (type === 'Offer' && res.id) { params.ids.push(res.id) }

so a real Wanted landed on myposts with {ids: [], type: 'Wanted'}, and the
panel gates on `type === 'Wanted' && ids.length` - unsatisfiable. The gate
was incidental rather than defensive: myposts is the only consumer of
history.state.ids, and both existing consumers (the donation ask and the
"viewed after Offer" event) gate on type === 'Offer' themselves, so
populating ids for both types affects nothing else.

The component spec passed because it stubbed {type:'Wanted', ids:[7]} - a
state the app never produced. Added tests on freegleIt itself asserting the
ids reach myposts for both types; they fail against the old gate.

Also: the cards showed no text at all on desktop. MessageSummary is
viewport-responsive, not container-responsive, so at lg+ it switches to a
horizontal list-row layout (fixed 200px photo + text beside it) that assumes
a full-width row. In a 15rem tile that left ~40px for the text, collapsing
the subject to zero width - a bare grey square. Widening can't fix it (the
subject needs ~292px; even a 512px card still clipped it), so keep the
vertical tile layout it already uses at md, and stack the type tag above the
title so the subject gets the full width.

Verified by posting through the real compose flow in a browser: state comes
back {ids:[9013], type:'Wanted'} and all four matches render with their full
subject, location, description and distance. Vitest 14273 pass.
freegleIt() now puts the posted ids in history state for both types, where
it used to do so for Offers only. ids is shared with the Give flow's landing
experience, but nothing asserted that the Offer-only donation ask keys off
the type rather than the presence of ids - so a regression there would have
been silent.

Cover both directions: the ask shows after an Offer, and stays away after a
Wanted even though ids now arrive for one.
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jul 17, 2026
…t button

- Drop the "Good news -" prefix from the panel heading.
- Give every match card the same height: the row stretches items to the tallest
  and the card's text area grows so the action pins to the bottom, aligning the
  buttons across the row.
- Add an empty #footer slot to MessageSummary so callers can place actions inside
  the card; WantedMatches uses it for a centred "View post" button (opens the
  offer in a new tab, preserving the just-posted wanted).
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running and removed CI: Passed CI build passed labels Jul 17, 2026
@edwh
edwh merged commit b35259d into master Jul 17, 2026
5 of 6 checks passed
edwh added a commit that referenced this pull request Jul 17, 2026
…-bounds schema

TestMatchesReachFilter and TestSimilarReachFiltered (#953) insert rippling_reach
rows without outer_bound; under the sandwich-bounds schema (#1098) that column
is GEOMETRY NOT NULL with no possible default, so the INSERT fails, no reach row
exists, and the out-of-reach candidate is never blocked. Seed the envelope of
the fixture polygon as outer_bound (band semantics: the exact polygon still
decides, so the tests' assertions are unchanged). #953 and #1098 crossed in
flight - each was green before the other merged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZAyCpXy9jiHzQGi6jfzdJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Blocked PR is blocked and cannot be merged CI: Running CI build is currently running

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants